const CommandRegistry = require('../src/command-registry')
const _ = require('underscore-plus')

describe('CommandRegistry', () => {
  let registry, parent, child, grandchild

  beforeEach(() => {
    parent = document.createElement('div')
    child = document.createElement('div')
    grandchild = document.createElement('div')
    parent.classList.add('parent')
    child.classList.add('child')
    grandchild.classList.add('grandchild')
    child.appendChild(grandchild)
    parent.appendChild(child)
    document.querySelector('#jasmine-content').appendChild(parent)

    registry = new CommandRegistry()
    registry.attach(parent)
  })

  afterEach(() => registry.destroy())

  describe('when a command event is dispatched on an element', () => {
    it('invokes callbacks with selectors matching the target', () => {
      let called = false
      registry.add('.grandchild', 'command', function (event) {
        expect(this).toBe(grandchild)
        expect(event.type).toBe('command')
        expect(event.eventPhase).toBe(Event.BUBBLING_PHASE)
        expect(event.target).toBe(grandchild)
        expect(event.currentTarget).toBe(grandchild)
        called = true
      })

      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(called).toBe(true)
    })

    it('invokes callbacks with selectors matching ancestors of the target', () => {
      const calls = []

      registry.add('.child', 'command', function (event) {
        expect(this).toBe(child)
        expect(event.target).toBe(grandchild)
        expect(event.currentTarget).toBe(child)
        calls.push('child')
      })

      registry.add('.parent', 'command', function (event) {
        expect(this).toBe(parent)
        expect(event.target).toBe(grandchild)
        expect(event.currentTarget).toBe(parent)
        calls.push('parent')
      })

      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual(['child', 'parent'])
    })

    it('invokes inline listeners prior to listeners applied via selectors', () => {
      const calls = []
      registry.add('.grandchild', 'command', () => calls.push('grandchild'))
      registry.add(child, 'command', () => calls.push('child-inline'))
      registry.add('.child', 'command', () => calls.push('child'))
      registry.add('.parent', 'command', () => calls.push('parent'))

      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent'])
    })

    it('orders multiple matching listeners for an element by selector specificity', () => {
      child.classList.add('foo', 'bar')
      const calls = []

      registry.add('.foo.bar', 'command', () => calls.push('.foo.bar'))
      registry.add('.foo', 'command', () => calls.push('.foo'))
      registry.add('.bar', 'command', () => calls.push('.bar')) // specificity ties favor commands added later, like CSS

      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual(['.foo.bar', '.bar', '.foo'])
    })

    it('orders inline listeners by reverse registration order', () => {
      const calls = []
      registry.add(child, 'command', () => calls.push('child1'))
      registry.add(child, 'command', () => calls.push('child2'))
      child.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual(['child2', 'child1'])
    })

    it('stops bubbling through ancestors when .stopPropagation() is called on the event', () => {
      const calls = []

      registry.add('.parent', 'command', () => calls.push('parent'))
      registry.add('.child', 'command', () => calls.push('child-2'))
      registry.add('.child', 'command', event => {
        calls.push('child-1')
        event.stopPropagation()
      })

      const dispatchedEvent = new CustomEvent('command', { bubbles: true })
      spyOn(dispatchedEvent, 'stopPropagation')
      grandchild.dispatchEvent(dispatchedEvent)
      expect(calls).toEqual(['child-1', 'child-2'])
      expect(dispatchedEvent.stopPropagation).toHaveBeenCalled()
    })

    it('stops invoking callbacks when .stopImmediatePropagation() is called on the event', () => {
      const calls = []

      registry.add('.parent', 'command', () => calls.push('parent'))
      registry.add('.child', 'command', () => calls.push('child-2'))
      registry.add('.child', 'command', event => {
        calls.push('child-1')
        event.stopImmediatePropagation()
      })

      const dispatchedEvent = new CustomEvent('command', { bubbles: true })
      spyOn(dispatchedEvent, 'stopImmediatePropagation')
      grandchild.dispatchEvent(dispatchedEvent)
      expect(calls).toEqual(['child-1'])
      expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled()
    })

    it('forwards .preventDefault() calls from the synthetic event to the original', () => {
      registry.add('.child', 'command', event => event.preventDefault())

      const dispatchedEvent = new CustomEvent('command', { bubbles: true })
      spyOn(dispatchedEvent, 'preventDefault')
      grandchild.dispatchEvent(dispatchedEvent)
      expect(dispatchedEvent.preventDefault).toHaveBeenCalled()
    })

    it('forwards .abortKeyBinding() calls from the synthetic event to the original', () => {
      registry.add('.child', 'command', event => event.abortKeyBinding())

      const dispatchedEvent = new CustomEvent('command', { bubbles: true })
      dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding')
      grandchild.dispatchEvent(dispatchedEvent)
      expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled()
    })

    it('copies non-standard properties from the original event to the synthetic event', () => {
      let syntheticEvent = null
      registry.add('.child', 'command', event => (syntheticEvent = event))

      const dispatchedEvent = new CustomEvent('command', { bubbles: true })
      dispatchedEvent.nonStandardProperty = 'testing'
      grandchild.dispatchEvent(dispatchedEvent)
      expect(syntheticEvent.nonStandardProperty).toBe('testing')
    })

    it('allows listeners to be removed via a disposable returned by ::add', () => {
      let calls = []

      const disposable1 = registry.add('.parent', 'command', () =>
        calls.push('parent')
      )
      const disposable2 = registry.add('.child', 'command', () =>
        calls.push('child')
      )

      disposable1.dispose()
      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual(['child'])

      calls = []
      disposable2.dispose()
      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))
      expect(calls).toEqual([])
    })

    it('allows multiple commands to be registered under one selector when called with an object', () => {
      let calls = []

      const disposable = registry.add('.child', {
        'command-1' () {
          calls.push('command-1')
        },
        'command-2' () {
          calls.push('command-2')
        }
      })

      grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }))
      grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true }))

      expect(calls).toEqual(['command-1', 'command-2'])

      calls = []
      disposable.dispose()
      grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }))
      grandchild.dispatchEvent(new CustomEvent('command-2', { bubbles: true }))
      expect(calls).toEqual([])
    })

    it('invokes callbacks registered with ::onWillDispatch and ::onDidDispatch', () => {
      const sequence = []

      registry.onDidDispatch(event => sequence.push(['onDidDispatch', event]))

      registry.add('.grandchild', 'command', event =>
        sequence.push(['listener', event])
      )

      registry.onWillDispatch(event => sequence.push(['onWillDispatch', event]))

      grandchild.dispatchEvent(new CustomEvent('command', { bubbles: true }))

      expect(sequence[0][0]).toBe('onWillDispatch')
      expect(sequence[1][0]).toBe('listener')
      expect(sequence[2][0]).toBe('onDidDispatch')

      expect(
        sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1]
      ).toBe(true)
      expect(sequence[0][1].constructor).toBe(CustomEvent)
      expect(sequence[0][1].target).toBe(grandchild)
    })
  })

  describe('::add(selector, commandName, callback)', () => {
    it('throws an error when called with an invalid selector', () => {
      const badSelector = '<>'
      let addError = null
      try {
        registry.add(badSelector, 'foo:bar', () => {})
      } catch (error) {
        addError = error
      }
      expect(addError.message).toContain(badSelector)
    })

    it('throws an error when called with a null callback and selector target', () => {
      const badCallback = null

      expect(() => {
        registry.add('.selector', 'foo:bar', badCallback)
      }).toThrow(new Error('Cannot register a command with a null listener.'))
    })

    it('throws an error when called with a null callback and object target', () => {
      const badCallback = null

      expect(() => {
        registry.add(document.body, 'foo:bar', badCallback)
      }).toThrow(new Error('Cannot register a command with a null listener.'))
    })

    it('throws an error when called with an object listener without a didDispatch method', () => {
      const badListener = {
        title: 'a listener without a didDispatch callback',
        description: 'this should throw an error'
      }

      expect(() => {
        registry.add(document.body, 'foo:bar', badListener)
      }).toThrow(
        new Error(
          'Listener must be a callback function or an object with a didDispatch method.'
        )
      )
    })
  })

  describe('::findCommands({target})', () => {
    it('returns command descriptors that can be invoked on the target or its ancestors', () => {
      registry.add('.parent', 'namespace:command-1', () => {})
      registry.add('.child', 'namespace:command-2', () => {})
      registry.add('.grandchild', 'namespace:command-3', () => {})
      registry.add('.grandchild.no-match', 'namespace:command-4', () => {})

      registry.add(grandchild, 'namespace:inline-command-1', () => {})
      registry.add(child, 'namespace:inline-command-2', () => {})

      const commands = registry.findCommands({ target: grandchild })
      const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery)
      expect(nonJqueryCommands).toEqual([
        {
          name: 'namespace:inline-command-1',
          displayName: 'Namespace: Inline Command 1'
        },
        { name: 'namespace:command-3', displayName: 'Namespace: Command 3' },
        {
          name: 'namespace:inline-command-2',
          displayName: 'Namespace: Inline Command 2'
        },
        { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
        { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
      ])
    })

    it('returns command descriptors with arbitrary metadata if set in a listener object', () => {
      registry.add('.grandchild', 'namespace:command-1', () => {})
      registry.add('.grandchild', 'namespace:command-2', {
        displayName: 'Custom Command 2',
        metadata: {
          some: 'other',
          object: 'data'
        },
        didDispatch () {}
      })
      registry.add('.grandchild', 'namespace:command-3', {
        name: 'some:other:incorrect:commandname',
        displayName: 'Custom Command 3',
        metadata: {
          some: 'other',
          object: 'data'
        },
        didDispatch () {}
      })

      const commands = registry.findCommands({ target: grandchild })
      expect(commands).toEqual([
        {
          displayName: 'Namespace: Command 1',
          name: 'namespace:command-1'
        },
        {
          displayName: 'Custom Command 2',
          metadata: {
            some: 'other',
            object: 'data'
          },
          name: 'namespace:command-2'
        },
        {
          displayName: 'Custom Command 3',
          metadata: {
            some: 'other',
            object: 'data'
          },
          name: 'namespace:command-3'
        }
      ])
    })

    it('returns command descriptors with arbitrary metadata if set on a listener function', () => {
      function listener () {}
      listener.displayName = 'Custom Command 2'
      listener.metadata = {
        some: 'other',
        object: 'data'
      }

      registry.add('.grandchild', 'namespace:command-2', listener)
      const commands = registry.findCommands({ target: grandchild })
      expect(commands).toEqual([
        {
          displayName: 'Custom Command 2',
          metadata: {
            some: 'other',
            object: 'data'
          },
          name: 'namespace:command-2'
        }
      ])
    })
  })

  describe('::dispatch(target, commandName)', () => {
    it('simulates invocation of the given command ', () => {
      let called = false
      registry.add('.grandchild', 'command', function (event) {
        expect(this).toBe(grandchild)
        expect(event.type).toBe('command')
        expect(event.eventPhase).toBe(Event.BUBBLING_PHASE)
        expect(event.target).toBe(grandchild)
        expect(event.currentTarget).toBe(grandchild)
        called = true
      })

      registry.dispatch(grandchild, 'command')
      expect(called).toBe(true)
    })

    it('returns a promise if any listeners matched the command', () => {
      registry.add('.grandchild', 'command', () => {})

      expect(registry.dispatch(grandchild, 'command').constructor.name).toBe(
        'Promise'
      )
      expect(registry.dispatch(grandchild, 'bogus')).toBe(null)
      expect(registry.dispatch(parent, 'command')).toBe(null)
    })

    it('returns a promise that resolves when the listeners resolve', async () => {
      jasmine.useRealClock()
      registry.add('.grandchild', 'command', () => 1)
      registry.add('.grandchild', 'command', () => Promise.resolve(2))
      registry.add(
        '.grandchild',
        'command',
        () =>
          new Promise(resolve => {
            setTimeout(() => {
              resolve(3)
            }, 1)
          })
      )

      const values = await registry.dispatch(grandchild, 'command')
      expect(values).toEqual([3, 2, 1])
    })

    it('returns a promise that rejects when a listener is rejected', async () => {
      jasmine.useRealClock()
      registry.add('.grandchild', 'command', () => 1)
      registry.add('.grandchild', 'command', () => Promise.resolve(2))
      registry.add(
        '.grandchild',
        'command',
        () =>
          new Promise((resolve, reject) => {
            setTimeout(() => {
              reject(3)
            }, 1)
          })
      )

      let value
      try {
        value = await registry.dispatch(grandchild, 'command')
      } catch (err) {
        value = err
      }
      expect(value).toBe(3)
    })
  })

  describe('::getSnapshot and ::restoreSnapshot', () =>
    it('removes all command handlers except for those in the snapshot', () => {
      registry.add('.parent', 'namespace:command-1', () => {})
      registry.add('.child', 'namespace:command-2', () => {})
      const snapshot = registry.getSnapshot()
      registry.add('.grandchild', 'namespace:command-3', () => {})

      expect(registry.findCommands({ target: grandchild }).slice(0, 3)).toEqual(
        [
          { name: 'namespace:command-3', displayName: 'Namespace: Command 3' },
          { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
          { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
        ]
      )

      registry.restoreSnapshot(snapshot)

      expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual(
        [
          { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
          { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
        ]
      )

      registry.add('.grandchild', 'namespace:command-3', () => {})
      registry.restoreSnapshot(snapshot)

      expect(registry.findCommands({ target: grandchild }).slice(0, 2)).toEqual(
        [
          { name: 'namespace:command-2', displayName: 'Namespace: Command 2' },
          { name: 'namespace:command-1', displayName: 'Namespace: Command 1' }
        ]
      )
    }))

  describe('::attach(rootNode)', () =>
    it('adds event listeners for any previously-added commands', () => {
      const registry2 = new CommandRegistry()

      const commandSpy = jasmine.createSpy('command-callback')
      registry2.add('.grandchild', 'command-1', commandSpy)

      grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }))
      expect(commandSpy).not.toHaveBeenCalled()

      registry2.attach(parent)

      grandchild.dispatchEvent(new CustomEvent('command-1', { bubbles: true }))
      expect(commandSpy).toHaveBeenCalled()
    }))
})
